feat: optimize Transaction Pay amount quote latency#9543
feat: optimize Transaction Pay amount quote latency#9543pedronfigueiredo wants to merge 8 commits into
Conversation
d284b3c to
732f0f7
Compare
|
|
||
| log('Beginning atomic batch update', request); | ||
|
|
||
| const updatedTransactionMeta = this.#updateTransactionInternal( |
There was a problem hiding this comment.
Is this duplicating lots of the updateAtomicBatchData logic?
Should we export a synchronous util from eip7702 such as updateEIP7702BatchData that accepts existing data and multiple new calls with an index to encapsulate?
There was a problem hiding this comment.
Good suggestion. I’ll extract a synchronous, exported updateEIP7702BatchData utility that validates indexed updates and returns the updated nested transactions plus encoded batch data, then use it from the controller.
Addressed in a5d05c5d2 (commit).
| ); | ||
|
|
||
| revision = (transactionMeta.transactionRevision ?? 0) + 1; | ||
| transactionMeta.requiredAssets = requiredAssets; |
There was a problem hiding this comment.
I'm nervous to couple lots of updates here as it's very context specific.
Something i've wanted to do for a long time is expose a new updateTransactionCallback action that essentially exposes updateTransactionInternal so callers can update multiple properties at once without passing the full metadata, so avoids race conditions.
Then maybe some of this could be client logic to update multiple values at once?
There was a problem hiding this comment.
Agreed. I’ll add a generic updateTransactionCallback messenger action that performs a callback-based atomic update with the controller’s normal validation and re-simulation behavior. I’ll keep this change to the reusable primitive and move the flow-specific updates in the follow-up changes.
Addressed in 836ecc496 (commit).
| draftTransaction: TransactionMeta, | ||
| revision: number, | ||
| ): Promise<AtomicBatchPreparationResult> { | ||
| await Promise.all([ |
There was a problem hiding this comment.
Do we even need either of these, could our update be entirely synchronous?
The target gas shouldn't matter as the quote dictates the source gas and the provider owns the target gas?
Similarly, the target simulation isn't used or rendered in MetaMask Pay?
For any non-MetaMask Pay scenarios, we could let the caller explicitly update gas and pass it to our new updateTransactionCallback? To avoid adding that overhead implicitly?
I've been meaning to go one step further and disable simulation and gas polling for all MetaMask Pay transactions too. That may not help performance, but would minimise unnecessary network overhead.
There was a problem hiding this comment.
Agreed. I’ll make the amount commit synchronous in Transaction Pay using updateEIP7702BatchData and updateTransactionCallback, remove the revision-bound gas/simulation preparation from the quote path, and leave explicit gas estimation to the existing non-Pay update API. I’ll keep the broader global simulation and gas-polling disablement separate.
Addressed in 411a67f55 (commit).
| * @param request - Complete atomic batch update. | ||
| * @returns The synchronously published revision and its preparation handle. | ||
| */ | ||
| beginAtomicBatchUpdate( |
There was a problem hiding this comment.
Following my other comment about a generic update method that accepts a callback, do we want to encourage single updates, or should we expose the synchronous updateEIP7702BatchData util I mentioned below, so callers could update data themselves, then pass to updateTransactionCallback?
So total flexibility to empower the caller to update efficiently in a single call, but generically so not limited to single flows?
| * @param request - Exact amount and transaction ID. | ||
| * @returns Whether the matching quote generation was published. | ||
| */ | ||
| updateAmount(request: UpdateAmountRequest): Promise<boolean> { |
There was a problem hiding this comment.
This is a cool mechanism to cancel wasted requests, but as per my other comment, do we even care about gas and simulation so is there anything async we need at all?
There was a problem hiding this comment.
Gas and simulation are no longer part of this path. The cancellation remains because amount preparation and vendor quote requests are still asynchronous, so it prevents stale intent and quote results from being published.
Addressed in 411a67f55 (commit).
| return { | ||
| revision, | ||
| transaction, | ||
| preparation: this.#prepareAtomicBatchUpdate(draftTransaction, revision), |
There was a problem hiding this comment.
This is clever, but we do similar for transaction lifecycle, returning promise properties, and it introduces some complexity around dangling promises, plus I fear it adds confusion to the interface.
So ideally, would it be safer to stick with explicit synchronous actions and full promise returns?
There was a problem hiding this comment.
Agreed. The preparation promise property and beginAtomicBatchUpdate action have been removed. Transaction updates now use the explicit synchronous updateTransactionCallback action, while updateAmount returns the full quote-pipeline promise.
Addressed in 411a67f55 (commit).
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 2 potential issues.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, have a team admin enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit 411a67f. Configure here.

Explanation
Context
Updating the amount of a Money Account deposit currently fans one logical user action out through multiple nested transaction updates, parent calldata rebuilds, controller publications, and quote generations. In the reference iOS simulator trace, Update/Done to an executable quote took 8.503 seconds:
Relay is a third-party request whose latency varies independently. This proof of concept targets the client-controlled work before and after that request.
What this PR changes
This PR adds generic controller primitives and an explicit Transaction Pay amount-update pipeline.
TransactionController
updateTransactionCallback, a synchronous messenger action that updates multiple transaction properties atomically against the latest metadata without requiring callers to pass a full, potentially stale transaction object.updateEIP7702BatchData, a pure synchronous utility that validates indexed nested-transaction calldata updates, returns a new nested-transaction list, and rebuilds parent EIP-7702 batch calldata once.updateAtomicBatchDataas the existing explicit asynchronous API for consumers that require gas estimation, while synchronously clearing stale gas and simulation metadata when new batch calldata is published.TransactionPayController
updateAmount, backed by a consumer-provided asynchronousprepareTransactionAmountcallback, so transaction-specific amount conversion and calldata construction remain outside the generic controller.updateEIP7702BatchDatainsideupdateTransactionCallbackto synchronously commit required assets, every nested calldata update, and parent batch calldata in one state update.Promise<boolean>for amount preparation and quote generation rather than exposing a separate promise property.Flow
A newer amount aborts the earlier amount preparation and quote request so late results cannot publish stale quotes.
Why start with Money Account deposits?
Money Account deposits expose the amplification clearly: an amount change updates approval and deposit calldata, requires
previewDeposit, and then feeds Transaction Pay and EIP-7702/delegation work. The flow is narrow enough to validate behind a Mobile feature flag while retaining the legacy path as a kill switch.The APIs are intentionally not Money Account-specific.
updateTransactionCallbackprovides generic atomic callback updates,updateEIP7702BatchDataprovides generic indexed EIP-7702 calldata updates, and Transaction Pay delegates flow-specific preparation to an injected callback.Benchmark result
A follow-up React Native DevTools trace used the same inferred Update/Done and executable-commit anchors:
The Relay request regressed by 1.011 seconds in the second sample even though this PR does not optimize Relay. Treating Relay as the same 4.108-second contribution in both traces gives a normalized PoC total of 6,426.691 ms, a 2,075.840 ms / 24.4% end-to-end improvement. Client-controlled time fell from 4,394.591 ms to 2,318.751 ms, a 47.2% reduction.
Structural trace evidence:
eth_estimateGascallsThese are two simulator traces, not production p50/p95 measurements. Relay latency should continue to be reported separately, and old/new runs should be alternated over multiple samples. The trace predates the final review-driven simplification that removed explicit gas/simulation preparation from the amount-update path, so it should be treated as directional evidence rather than a final-code benchmark.
Correctness and rollout constraints
Future work
Validation
yarn workspace @metamask/transaction-controller run testyarn workspace @metamask/transaction-pay-controller run test— 100% statement, branch, function, and line coverageyarn lintgit diff --checkpass for changed files.References
28e517a73d33db3aa63470f9bc55bdbb47fc61060b09227dce3c5df2d4ae13a9e80c86da8913924a228627d851ab75965174a30c7607cee1416917aa2c34fChecklist
Note
Medium Risk
Touches core transaction state updates and EIP-7702 batch calldata paths used before quoting; incorrect patches or race handling could publish stale gas/simulation data or duplicate quotes, though validation and synchronous metadata clearing mitigate this.
Overview
Adds TransactionController primitives so callers can mutate transaction metadata in one shot and rebuild EIP-7702 batch calldata without stale read-modify-write:
updateTransactionCallback(messenger action) and exportedupdateEIP7702BatchData.updateAtomicBatchDatanow uses that helper and clears gas, simulation, and security-alert metadata synchronously when nested calldata changes, before async gas estimation finishes.TransactionPayController gains
updateAmountwith an injectedprepareTransactionAmountcallback. The flow aborts in-flight quotes, validates a complete nested-calldata patch, commits required assets + all nested updates + parent batch data viaupdateTransactionCallback, deduplicates identical in-flight intents, supersedes conflicting ones, and runs one explicit quote generation (listener quote launches suppressed during the atomic commit).abortQuotesand optionalsignalonupdateQuotestie quote cancellation to amount updates.Reviewed by Cursor Bugbot for commit 366f7b7. Bugbot is set up for automated code reviews on this repo. Configure here.